Repository navigation
fix(cash): keep a module's authority over its own pending withdrawal after de-whitelisting - #350
Open
simonedamico wants to merge 6 commits into
Open
simonedamico wants to merge 6 commits into
simonedamico wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Verification Results
- Group ID: a49ffa13-1154-48c4-a1a7-773e978cb63f
- Commit: 551fc55
| Job | Result | VERIFIED |
|---|---|---|
| EtherFiSafe.… | ✅ | 14 |
| CashModuleCo… | ✅ | 2 |
simonedamico
force-pushed
the
claude/cashmodule-module-withdrawal-authority
branch
from
October 26, 2026 11:25
551fc55 to
f99154a
Compare
There was a problem hiding this comment.
Verification Results
- Group ID: adfa26bb-770a-40b5-a0e8-6c6c789b5c91
- Commit: f99154a
| Job | Result | VERIFIED |
|---|---|---|
| EtherFiSafe.… | ✅ | 14 |
| CashModuleCo… | ✅ | 2 |
…after de-whitelisting CashModule decided who may process or cancel a module's pending withdrawal from current whitelist membership. Once governance removed the module from the withdraw-requester list or the DataProvider whitelist, anyone could process the hold into the module (bypassing the module's bookkeeping, which strands funds in an escrow like PaymentLinkModule), the module could no longer cancel it, and its cancelBridgeByCashModule callback was skipped. requestWithdrawalByModule now records the request as the module's own (SafeCashConfig.withdrawalRequestedByModule, appended at slot 17 offset 15, packed after usesLendGateway). Processing, owner cancel and module cancel read that record, so removal only blocks new requests. Requests without the record keep the current-membership rules. On cancel, a module still on both whitelists is called as before. A removed module whose request carries the record is still notified, through a call capped at 200k gas whose result and return data are ignored, so a broken or retired module cannot block spends, liquidations or the owners' replacement request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…telist CashModule-level tests with a mock module (both removal orders and single-list removals, reverting and gas-burning callbacks, the record's slot and lifecycle, requests without the record) and StockWithdrawModule tests for both removal orders. The existing test that pinned the old DataProvider-removal revert now asserts the module can still cancel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local CashModuleCore and CashModuleSetters now carry the per-request module withdrawal record, so they no longer match the deployed implementations (both checks pass on master). Same pattern as the EtherFiSafeFactory skip: re-enable once the CashModule upgrade is live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut gas starvation From the Codex review: the StockWithdrawModule callback gas check now places the order in setUp, so the callback deletes persisted storage in a later transaction (68,160 gas against the 200k cap). A new test binary-searches the outer gas limit of a relayed owner replacement and requires every successful probe to have delivered the capped callback. The natspec now says plainly that a removed module's callback still runs; it can only no longer block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le callback From the second Codex pass: a binary search alone assumes success is monotonic in gas. Both starvation tests now also sweep every outer gas limit from far below the cheapest successful call to 100k above it in 1k steps, and require each success to have delivered the callback. The StockWithdrawModule version runs against the real module with the order and its removal persisted in setUp. A 40k cap mutation fails both StockWithdrawModule callback tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay override After rebasing onto master's per-module withdrawal delays, a module whose override is zero (global delay nonzero) processes its request inline. The record is written before that processing and cleared by it. Suggested by the Codex review of the rebase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonedamico
force-pushed
the
claude/cashmodule-module-withdrawal-authority
branch
from
October 29, 2026 18:56
f99154a to
51baee7
Compare
There was a problem hiding this comment.
Verification Results
- Group ID: be8cae8d-277c-48b3-9ae4-973c3218a151
- Commit: 51baee7
| Job | Result | VERIFIED |
|---|---|---|
| EtherFiSafe.… | ✅ | 14 |
| CashModuleCo… | ✅ | 2 |
simonedamico
marked this pull request as ready for review
October 31, 2026 06:29
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
simonedamico
added a commit
that referenced
this pull request
Nov 1, 2026
Whether production enablement waits for cash-v3#350 is an open release-checklist decision; the 3CP header now says the governance rule applies unless #350 is live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonedamico
added a commit
that referenced
this pull request
Nov 4, 2026
Whether production enablement waits for cash-v3#350 is an open release-checklist decision; the 3CP header now says the governance rule applies unless #350 is live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Abstract
Modules such as StockWithdrawModule (and PaymentLinkModule, #349) move a safe's funds by asking CashModule for a withdrawal with the module as recipient. Today, if governance removes such a module from CashModule's withdraw-requester list or the DataProvider whitelist while one of its withdrawals is pending, CashModule stops treating that withdrawal as the module's. This PR records which pending withdrawals a module requested, so removing a module blocks only its new requests.
How module withdrawals work
A safe has at most one pending withdrawal. A module requests it, CashModule holds it for the withdrawal delay, and then it is processed into the module. A card spend, a
preLiquidate, or the owners' own withdrawal cancels it and calls the module back so the module can update its records.What goes wrong today after a module is removed while its withdrawal is pending:
processWithdrawalthe hold into the module, skipping the module's bookkeeping. For PaymentLinkModule, the sender's funds arrive as unrecorded surplus.cancelOldWithdrawalskips the callback, so the module's record goes stale.Until this PR is deployed, #349 relies on a governance rule: never de-whitelist PaymentLinkModule while a link is
Requested.What changes
requestWithdrawalByModulesetsSafeCashConfig.withdrawalRequestedByModule. Every path that deletes the pending request, whether processing or cancelling, clears it.CashLendLib.isModuleWithdrawal(safe)holds when the record is set or the recipient is still on the requester list. Processing, the owners'cancelWithdrawalandcancelWithdrawalByModuleall use it, so:cancelExpiredWithdrawalor a replacement withdrawal (tested)._notifyRemovedModule: a call capped at 200k gas whose result is ignored. A broken or retired module therefore can't block spends,preLiquidateor owner replacements.Merge order with #349
#349's tests pin today's CashModule behavior, so they fail against this change.
claude/plm-tests-on-cashmodule-fixis #349 with this branch merged in and its tests updated (101 passed). Whichever of the two PRs merges second carries those test changes.Storage layout
One bool is appended at the end of
SafeCashConfig, a mapping value, and packs into its existing last slot. The struct size is unchanged, andWithdrawalRequestandSafeData(thegetDataABI) are untouched.CashModuleStorageitself is unchanged at 544 bytes, including after the rebase onto master'smoduleWithdrawalDelayConfig.test_record_packsIntoSafeCashConfigTailSlotpins the slot and offset.t_struct(SafeCashConfig) numberOfBytes=576 slot 17 off 0 incomingMode t_enum(Mode) slot 17 off 1 lendOptedOut t_bool slot 17 off 2 lendOptOutFinalizeTime t_uint96 slot 17 off 14 usesLendGateway t_bool + slot 17 off 15 withdrawalRequestedByModule t_boolUpgrade path (not in this PR)
CashLendLib(a linked library), then newCashModuleCoreandCashModuleSettersimplementations through CREATE3 with fresh salts, both linked to that library.upgradeToAndCall(core, "")andsetCashModuleSettersAddress(setters), then a verify script. Both must land together: a mixed old and new pair could create requests without the record or leave a stale one.test_verifyBytecode_CashModuleCoreand_CashModuleSettersinVerifyOPMainnet.t.solare skipped until the upgrade is live.CI
Every check passes except
Run Tests (deployed), which fails only ontest_verifyBytecode_TopUpDest, exactly as on master. On 2026-10-22 the TopUpDest proxy on Optimism was upgraded to code from #331, which hasn't merged, so master'sTopUpDest.solno longer matches. The check passes once #331 lands.Testing
New
test/safe/modules/cash/ModuleWithdrawalAuthority.t.sol(mock module, 30 tests) covers:preLiquidate;StockWithdrawModule.t.solcovers both removal orders against the real module, with a 1k-step sweep of the outer gas limit.Withdrawals.t.solnow asserts that the module can still cancel after a DataProvider removal.Run locally on an Optimism fork:
test/safe/modules/cash/**: 457 passed, before the last commit added one test;ModuleWithdrawalAuthority.t.solre-run afterward: 30 passed;StockWithdrawModule.t.sol: 59 passed;lendprofile suites: 133 passed;VerifyOPMainnet.t.sol: 38 passed, 5 skipped, and only the TopUpDest check failed, as on master.Follow-ups (not in this PR)
reconcile, so a missed callback can't strand an order.VerifyOPMainnetchecks, and scope [PAY-1170] Add PaymentLinkModule and deploy scripts #349's governance rule to pre-upgrade holds.🤖 Generated with Claude Code
Note
Medium Risk
Changes CashModule withdrawal authority and cancel callbacks affecting bridge modules (StockWithdraw, PaymentLink), but behavior is narrowed and guarded with gas-capped notifications; requires a coordinated impl upgrade so requests always carry the new record.
Overview
Fixes a governance edge case where de-whitelisting a withdraw-requesting module while its hold was pending let third parties process the withdrawal into the module (skipping bridge bookkeeping) and blocked the module from canceling it;
cancelOldWithdrawalalso stopped callingcancelBridgeByCashModuleafter DataProvider removal.requestWithdrawalByModulenow sets a per-requestwithdrawalRequestedByModuleflag (packed inSafeCashConfig).CashLendLib.isModuleWithdrawaltreats a hold as module-owned when that flag is set or the recipient is still on the withdraw-requester list (legacy behavior for older requests). Process, owner cancel, andcancelWithdrawalByModuleuse this helper so removal only blocks new requests—the recipient module keeps exclusive process/cancel rights, including after DataProvider delist when the flag is set.When a competing cancel clears a recorded module hold and the module is no longer on both whitelists,
cancelOldWithdrawalinvokes_notifyRemovedModule: a 200k-gas-capped, best-effortcancelBridgeByCashModulecall whose success/revert cannot block the cancel (spends, liquidation prep, owner replacement). Whitelisted modules still get the strict callback that can revert the cancel.Extensive tests cover mock and StockWithdrawModule flows; OP bytecode verify for CashModule impls is temporarily skipped pending deploy.
Reviewed by Cursor Bugbot for commit 51baee7. Bugbot is set up for automated code reviews on this repo. Configure here.
Need help on this PR? Tag
@codesmith-botwith what you need.